/* 
 * Licensed to Aduna under one or more contributor license agreements.  
 * See the NOTICE.txt file distributed with this work for additional 
 * information regarding copyright ownership. 
 *
 * Aduna licenses this file to you under the terms of the Aduna BSD 
 * License (the "License"); you may not use this file except in compliance 
 * with the License. See the LICENSE.txt file distributed with this work 
 * for the full License.
 *
 * Unless required by applicable law or agreed to in writing, software 
 * distributed under the License is distributed on an "AS IS" BASIS, 
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 
 * implied. See the License for the specific language governing permissions
 * and limitations under the License.
 */
 
package info.aduna.platform;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Iterator;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Launches a process, redirecting the output of that sub-process to the output
 * of this (the parent) process.
 */
public final class ProcessLauncher {

	private Logger logger = LoggerFactory.getLogger(this.getClass());
	
	private String commandLine;

	private String[] commandArray;

	private File baseDir;

	private ArrayList<OutputListener> listeners = new ArrayList<OutputListener>(1);

	private Process subProcess;

	private boolean finished = false;

	private StringBuilder out = new StringBuilder();

	private StringBuilder err = new StringBuilder();

	/**
	 * Constructs a new ProcessLauncher with the given command line.
	 */
	public ProcessLauncher(String commandLine) {
		this(commandLine, null);
	}

	public ProcessLauncher(String commandLine, File baseDir) {
		this.commandLine = commandLine;
		this.baseDir = baseDir;
	}

	/**
	 * Constructs a new ProcessLauncher with the given command array.
	 */
	public ProcessLauncher(String[] commandArray) {
		this(commandArray, null);
	}

	public ProcessLauncher(String[] commandArray, File baseDir) {
		this.commandArray = commandArray;
		this.baseDir = baseDir;
	}

	/**
	 * Constructs new process launcher with the given command element list.
	 */
	public ProcessLauncher(ArrayList<?> commandList) {
		this(commandList, null);
	}

	public ProcessLauncher(ArrayList<?> commandList, File baseDir) {
		this(toStringArray(commandList), baseDir);
	}

	private static <T> String[] toStringArray(ArrayList<T> list) {
		String[] result = new String[list.size()];
		Iterator<T> iter = list.iterator();
		int arrayIndex = 0;
		while (iter.hasNext()) {
			result[arrayIndex++] = iter.next().toString();
		}
		return result;
	}

	/**
	 * Classes implementing this interface can receive output generated by
	 * processes launched using the ProcessLauncher.
	 */
	public interface OutputListener {

		public void standardOutput(char[] output);

		public void errorOutput(char[] output);
	}

	/**
	 * Add a listener for output from the to-be-launched process.
	 */
	public void addOutputListener(OutputListener listener) {
		this.listeners.add(listener);
	}

	/** fire error output event */
	private void fireErr(char[] err) {
		if (this.listeners.isEmpty()) {
			this.err.append(out);
		}
		Iterator<OutputListener> iter = this.listeners.iterator();
		while (iter.hasNext()) {
			iter.next().errorOutput(err);
		}
	}

	/** fire standard output event */
	private void fireOut(char[] out) {
		if (this.listeners.isEmpty()) {
			this.out.append(out);
		}
		Iterator<OutputListener> iter = this.listeners.iterator();
		while (iter.hasNext()) {
			iter.next().standardOutput(out);
		}
	}

	/**
	 * Get standard output, in case no listeners were registered - never returns
	 * null.
	 */
	public String getStandardOutput() {
		if (!this.listeners.isEmpty()) {
			throw new IllegalStateException("Cannot get standard output, because outputlisteners have been registered.");
		}
		return this.out.toString();
	}

	/**
	 * Get error output, in case no listeners were registered - never returns
	 * null.
	 */
	public String getErrorOutput() {
		if (!this.listeners.isEmpty()) {
			throw new IllegalStateException("Cannot get error output, because outputlisteners have been registered.");
		}
		return this.err.toString();
	}

	/**
	 * Get the commandline that is used to launch the process.
	 */
	public String getCommandLine() {
		if (this.commandLine != null) {
			return this.commandLine;
		}
		else if (this.commandArray != null) {
			StringBuilder result = new StringBuilder(64);
			for (int i = 0; i < this.commandArray.length; i++) {
				if (i > 0) {
					result.append(' ');
				}
				result.append(this.commandArray[i]);
			}
			return result.toString();
		}
		else {
			return null;
		}
	}

	/**
	 * Check whether execution has finished.
	 */
	public boolean hasFinished() {
		return finished;
	}

	/**
	 * Launches the process, and blocks until that process completes execution.
	 *
	 * @throws CommandNotExistsException
	 *         If the command could not be executed because it does not exist
	 */
	public int launch() throws CommandNotExistsException {
		this.err.setLength(0);
		this.out.setLength(0);
		BackgroundPrinter stdout = null;
		BackgroundPrinter stderr = null;
		try {
			if (this.commandArray != null) {
				this.subProcess = Runtime.getRuntime().exec(this.commandArray, null, this.baseDir);
			}
			else {
				this.subProcess = Runtime.getRuntime().exec(this.commandLine, null, this.baseDir);
			}
			stdout = new BackgroundPrinter(subProcess.getInputStream(), false);
			stderr = new BackgroundPrinter(subProcess.getErrorStream(), true);
			stdout.start();
			stderr.start();
			// kill process and wait max 10 seconds for output to complete
			int exitValue = this.subProcess.waitFor();
			stdout.join(10000);
			stderr.join(10000);
			if (exitValue != 0) {
				logger.info("WARNING: exit value " + exitValue + " for command \"" + getCommandLine() + "\"");
			}
			return exitValue;
		}
		catch (IOException ioe) {
			// usually caused if the command does not exist at all
			throw new CommandNotExistsException("Command probably does not exist: " + ioe);
		}
		catch (Exception e) {
			logger.error("Exception while running/launching \"" + getCommandLine() + "\".", e);
		}
		finally {
			if (this.subProcess != null) {
				this.subProcess.destroy();
				this.subProcess = null;
			}
			if (stdout != null) {
				stdout.close();
			}
			if (stderr != null) {
				stderr.close();
			}
			this.finished = true;
		}
		return -1;
	}

	/**
	 * Tries to abort the currently running process.
	 */
	public void abort() {
		if (this.subProcess != null) {
			this.subProcess.destroy();
			this.subProcess = null;
		}
	}

	/**
	 * Catches output from a "java.lang.Process" and writes it to either
	 * System.err or System.out.
	 */
	private class BackgroundPrinter extends Thread {

		private InputStream in;

		boolean isErrorOutput;

		public BackgroundPrinter(InputStream in, boolean isErrorOutput) {
			this.in = in;
			this.isErrorOutput = isErrorOutput;
		}

		public void run() {
			try {
				BufferedReader reader = new BufferedReader(new InputStreamReader(this.in));
				// read buffer
				char[] buf = new char[1024];
				// write data to target, until no more data is left to read
				int numberOfReadBytes;
				while ((numberOfReadBytes = reader.read(buf)) != -1) {
					char[] clearedbuf = new char[numberOfReadBytes];
					System.arraycopy(buf, 0, clearedbuf, 0, numberOfReadBytes);
					if (this.isErrorOutput) {
						fireErr(clearedbuf);
					}
					else {
						fireOut(clearedbuf);
					}
				}
				/*
				 * } catch (IOException ioe) { // ignore this: process has
				 * ended, causing IOException } catch (NullPointerException ioe) { //
				 * ignore this: there was no resulting output
				 */
			}
			catch (Exception e) {
				logger.warn("Exception while reading from stream from subprocess.", e);
			}
		}

		public void close() {
			try {
				this.in.close();
			}
			catch (Exception e) {
				logger.warn("Closing background stream for launched process caused exception.", e);
			}
		}
	}

	/**
	 * Exception that is thrown when a command could not be executed because it
	 * (probably) does not exist at all.
	 */
	public static class CommandNotExistsException extends RuntimeException {

		private static final long serialVersionUID = -3770613178610919742L;

		/**
		 * Construct a new exception for a command that does not exist.
		 *
		 * @param msg
		 *        The message for this exception.
		 */
		public CommandNotExistsException(String msg) {
			super(msg);
		}
	}
}
